1"use client";
2
3
17import { Suspense, useEffect, useRef, useState } from "react";
18import { useParams, useSearchParams, useRouter } from "next/navigation";
19import { ArrowDownWideNarrow, Search, Loader2, ShieldAlert, SearchX, Sparkles } from "lucide-react";
20import { searchApi, isApiError } from "@/lib/api";
21import { useWorkspace } from "@/hooks/use-workspace";
22import { SEARCH_DEBOUNCE_MS, deepLinkFor } from "@/lib/search";
23import { ResultsList } from "@/components/search/results-list";
24import { AskAiSearch } from "@/components/search/ask-ai-search";
25import type { SearchResult } from "@/lib/types";
26
27export default function SearchPage() {
28
29 return (
30 <Suspense
31 fallback={
32 <div className="mx-auto max-w-2xl px-6 py-8">
33 <p className="text-sm text-muted-foreground">Loading search…</p>
34 </div>
35 }
36 >
37 <SearchPageInner />
38 </Suspense>
39 );
40}
41
42function SearchPageInner() {
43 const params = useParams<{ workspace: string }>();
44 const workspaceSlug = params?.workspace ?? "";
45 const { data: workspace } = useWorkspace(workspaceSlug);
46 const router = useRouter();
47 const searchParams = useSearchParams();
48
49
50 const [input, setInput] = useState(() => searchParams.get("q") ?? "");
51 const [q, setQ] = useState(() => (searchParams.get("q") ?? "").trim());
52
53
54 const sortParam = searchParams.get("sort");
55 const [sortBy, setSortBy] = useState<SortMode>(isSortMode(sortParam) ? sortParam : "relevance");
56 const [hits, setHits] = useState<SearchResult[] | null>(null);
57 const [loading, setLoading] = useState(false);
58 const [error, setError] = useState<ErrorKind>(null);
59 const reqRef = useRef(0);
60
61
62 const [askOpen, setAskOpen] = useState(false);
63 const askRef = useRef<HTMLDivElement>(null);
64
65
66 useEffect(() => {
67 const t = setTimeout(() => setQ(input.trim()), SEARCH_DEBOUNCE_MS);
68 return () => clearTimeout(t);
69 }, [input]);
70
71 useEffect(() => {
72 if (!q) {
73 setHits(null);
74 return;
75 }
76 const my = (reqRef.current += 1);
77 const controller = new AbortController();
78 setLoading(true);
79 setError(null);
80 searchApi
81 .search({ q, workspaceId: workspace?.id, limit: 30, sortBy }, controller.signal)
82 .then((res) => {
83 if (my !== reqRef.current) return;
84 setHits(res.data);
85 })
86 .catch((e: unknown) => {
87 if (my !== reqRef.current) return;
88 if (e instanceof DOMException && e.name === "AbortError") return;
89 setError(toErrorKind(e));
90 })
91 .finally(() => {
92 if (my === reqRef.current) setLoading(false);
93 });
94 return () => controller.abort();
95 }, [q, sortBy, workspace?.id]);
96
97
98 useEffect(() => {
99 const next = new URLSearchParams(searchParams.toString());
100 if (q) next.set("q", q);
101 else next.delete("q");
102
103 if (sortBy !== "relevance") next.set("sort", sortBy);
104 else next.delete("sort");
105 if (next.toString() !== searchParams.toString()) {
106 router.replace(`/w/${workspaceSlug}/search${next.toString() ? `?${next.toString()}` : ""}`, { scroll: false });
107 }
108 }, [q, sortBy, searchParams, router, workspaceSlug]);
109
110 const slug = workspace?.slug ?? workspaceSlug;
111
112 return (
113 <div className="mx-auto max-w-2xl px-6 py-8">
114 <div className="mb-4 flex items-center justify-between gap-3">
115 <h1 className="text-xl font-semibold">Search</h1>
116 <div className="flex items-center gap-2">
117 {}
119 <label className="relative inline-flex items-center" aria-label="Sort results by">
120 <ArrowDownWideNarrow className="pointer-events-none absolute left-2 size-3.5 text-muted-foreground" aria-hidden />
121 <select
122 data-testid="search-sort"
123 value={sortBy}
124 onChange={(e) => setSortBy(e.target.value as SortMode)}
125 className="rounded-md border border-input bg-background py-1.5 pl-7 pr-2 text-xs text-foreground/80 outline-none focus-visible:ring-1 focus-visible:ring-ring"
126 >
127 <option value="relevance">Relevance</option>
128 <option value="createdAt">Newest</option>
129 <option value="updatedAt">Recently updated</option>
130 </select>
131 </label>
132 {}
135 <button
136 type="button"
137 data-testid="search-ask-ai"
138 onClick={() => {
139 setAskOpen((open) => {
140 const next = !open;
141 if (next) requestAnimationFrame(() => askRef.current?.scrollIntoView({ behavior: "smooth", block: "nearest" }));
142 return next;
143 });
144 }}
145 aria-expanded={askOpen}
146 className="inline-flex items-center gap-1.5 rounded-md border border-border px-2.5 py-1.5 text-xs text-foreground/80 hover:bg-accent hover:text-foreground"
147 >
148 <Sparkles className="size-3.5" aria-hidden />
149 Ask AI
150 </button>
151 </div>
152 </div>
153 <div className="relative">
154 <Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" aria-hidden />
155 <input
156 autoFocus
157 className="w-full rounded-md border border-input bg-background py-2.5 pl-9 pr-3 text-sm outline-none focus-visible:ring-1 focus-visible:ring-ring"
158 placeholder="Search pages, databases, records, comments, files…"
159 value={input}
160 onChange={(e) => setInput(e.target.value)}
161 aria-label="Search"
162 />
163 </div>
164
165 {}
168 {askOpen && workspace ? (
169 <div ref={askRef} className="mt-4" data-testid="search-rag-card">
170 <AskAiSearch
171 workspaceId={workspace.id}
172 workspaceSlug={slug}
173 initialQuery={q}
174 variant="card"
175 />
176 </div>
177 ) : null}
178
179 <div className="mt-5">
180 {loading && (
181 <p className="inline-flex items-center gap-2 text-sm text-muted-foreground" role="status">
182 <Loader2 className="size-4 animate-spin" aria-hidden /> Searching…
183 </p>
184 )}
185
186 {error !== null && !loading ? (
187 error === "unavailable" ? (
188 <div className="flex flex-col items-center gap-2 rounded-md border border-border px-4 py-8 text-center" role="alert">
189 <ShieldAlert className="size-6 text-muted-foreground" aria-hidden />
190 <p className="text-sm text-foreground">Search is temporarily unavailable — try again.</p>
191 <p className="text-xs text-muted-foreground">Keep typing to retry — your query is preserved.</p>
192 </div>
193 ) : (
194 <p className="text-sm text-destructive" role="alert">
195 Search failed. Check your connection and keep typing to retry.
196 </p>
197 )
198 ) : null}
199
200 {!loading && error === null && q && hits && hits.length === 0 && (
201 <p className="flex items-center gap-2 text-sm text-muted-foreground">
202 <SearchX className="size-4" aria-hidden />
203 No results for “{q}”.
204 </p>
205 )}
206 {!q && !loading && (
207 <p className="text-sm text-muted-foreground">
208 Type to search this workspace — or press <kbd className="rounded border border-border px-1 text-[10px]">⌘K</kbd> anywhere.
209 </p>
210 )}
211
212 {hits && hits.length > 0 && error === null ? (
213 <div className="mt-4">
214 <ResultsList
215 items={hits}
216 workspaceSlug={slug}
217 onActivate={(item) => {
218 const link = deepLinkFor(item, slug);
219 if (link) router.push(link);
220 }}
221 />
222 </div>
223 ) : null}
224 </div>
225 </div>
226 );
227}
228
229
230type SortMode = "relevance" | "createdAt" | "updatedAt";
231const SORT_MODES: readonly SortMode[] = ["relevance", "createdAt", "updatedAt"];
232function isSortMode(v: string | null): v is SortMode {
233 return !!v && (SORT_MODES as readonly string[]).includes(v);
234}
235
236type ErrorKind = null | "unavailable" | "generic";
237
238function toErrorKind(e: unknown): ErrorKind {
239 if (isApiError(e)) {
240 if (e.code === "SEARCH_UNAVAILABLE" || e.status === 503) return "unavailable";
241 return "generic";
242 }
243 return "generic";
244}
245